feat: Signing & Trust v1 (milestone 2) - #203
Open
michael-herwig wants to merge 14 commits into
Open
Conversation
This was referenced Jul 9, 2026
Keyless Sigstore signing and verification surfaced as OCI Referrers artifacts. Adds oci::sign / oci::verify / oci::referrer pipelines (Fulcio cert, OIDC ambient + browser flows, Rekor log, trust root, identity policy), package_manager sign/verify tasks, the ocx package sign / ocx package verify CLI commands, a typed error taxonomy with dedicated exit codes (RekorUnavailable 83, ReferrersUnsupported 84), and OCX_IDENTITY_TOKEN handling that is never forwarded to subprocess children.
Planning artifacts (ADRs, PRDs, PR-FAQs, research, swarm review rounds, Codex cross-model gates), exit-code rule additions (RekorUnavailable 83, ReferrersUnsupported 84), subsystem-cli command rows, and a structural test asserting every command file has a table row.
Upgrade the primary acceptance registry from registry:2 to zot v2.1.18, which serves the OCI 1.1 Referrers API natively (GET /v2/<name>/referrers/ <digest> + OCI-Subject on push). distribution (BOTH registry:2 and registry:3) does NOT implement the Referrers API — verified empirically: registry:3 (distribution 3.1.1) returns Go's "404 page not found" on the referrers route and omits OCI-Subject. OCX is referrers-only with no tag-schema fallback (#106), so the supply-chain sign/verify path (#194) and the smoke test require a real referrers endpoint. zot is pinned to a multi-arch tag (linux/amd64 + linux/arm64) with a mounted read-only config. Keep the single registry:2 (mirror-registry, port 5001) as the permanent referrers-NEGATIVE fixture, exposed via a new `legacy_registry` fixture for test_referrers_capability.py (#106); it still backs the mirror tests. Add raw-HTTP referrers helpers (push_blob / push_manifest / push_minimal_ image / push_referrer / list_referrers) to src/registry.py and a smoke test (test_referrers_smoke.py) that pushes a subject + referrer to zot and lists it back, and asserts the registry:2 negative fixture does not serve referrers. Refs #195.
Fill the slice-1 scaffold with a working keyless Sigstore sign + verify pipeline over OCI 1.1 Referrers: - Sign: ephemeral P-256 key -> Fulcio cert -> ECDSA sign of the subject digest -> Rekor hashedrekord upload -> Sigstore bundle v0.3 -> push bundle blob + referrer manifest. OIDC dispatch (override -> GHA/GitLab/ CircleCI ambient -> browser/no-tty). Capability cache wired (exit 84). - Verify: list referrers -> fetch bundle -> validate Fulcio chain vs injected trust root -> bind message signature to subject digest -> ECDSA verify -> Rekor SET verify (Ed25519, key fetched from --rekor-url) -> byte-equal identity + issuer match. - NativeTransport list_referrers/push_referrer_manifest over the OCI 1.1 Referrers API incl. server-side ?artifactType= filter. - Trust-root seam: --trust-root flag + OCX_SIGSTORE_TRUST_ROOT env (Fulcio CA PEM); embedded/TUF default is network-gated. - Endpoint lift to oci::endpoint (ADR Amendment 2). - deps: sigstore 0.14 + sigstore_protobuf_specs + p256/x509-cert/ ed25519-dalek + reqwest (rustls). rsa RUSTSEC-2023-0071 ignored in deny.toml (transitive via openidconnect; OCX is ECDSA-only). Spike + plan: research_sigstore_rs_spike.md, plan_issue194_sigstore_pipeline.md. Hand-rolls the HTTP because sigstore-rs's high-level clients mandate an SCT and a different wire shape than the offline fake stack.
Extend `ocx package verify` with trust-policy identity pinning so a typosquat carrying a valid-but-wrong Sigstore identity is rejected. - New leaf module `crate::trust`: `[[trust.policy]]` schema (scope + identity XOR identity_regexp + oidc_issuer), literal-prefix scope match, most-specific-wins + ANY-of-among-equal resolution, anchored full-match regex identities. - Cross-tier precedence (security): operator `config.toml` tiers array-append into one authoritative set; `resolve_tiered` consults the project `ocx.toml` only for scopes no operator policy governs, so a project file can never override or weaken an operator pin. - CLI: `--certificate-identity`/`--certificate-oidc-issuer` become optional (both-or-neither via clap `requires`); both given override policy (flag-only verify unchanged); neither uses the matched policy set. New `VerifyErrorKind::NoIdentityProvided` (exit 64) and `TrustPolicyInvalid` (exit 78). - `trust` field on `Config` + `ProjectConfig` (excluded from the declaration hash so a policy edit never invalidates `ocx.lock`). Verify reading `ocx.toml` for trust policy is a documented OCI-tier carve-out, scoped solely to `[[trust.policy]]`.
Make `ocx package verify` offline-capable and close #194's Rekor-key trust-on-first-use hole. - Trust-root cache in `$OCX_HOME/state/trust_root/<rekor-authority>.json` (mirrors the referrers capability cache): a successful online verify caches the Fulcio CA + Rekor key it used; a later verify reuses it. - `--tuf-root` / `OCX_SIGSTORE_TUF_ROOT`: load a Sigstore trusted-root JSON (Fulcio CA + pinned Rekor key) for air-gapped verify. No TUF network fetch — deferred, documented. - Rekor key pinning: verify prefers the trust-root Rekor key (TUF/cache) over the network TOFU fetch, falling back to fetch only when online and unpinned. - Offline semantics: for verify, `--offline`/`OCX_OFFLINE` scopes to the Sigstore trust services (Rekor-key fetch, TUF), not the artifact registry. Offline verify requires cached/supplied trust material with a pinned Rekor key; with none it fails exit 78 naming the remedy — never silently skips. `sign` stays online-only. The cache read + into_trust_root is the reusable offline decision seam for #99. Docs: environment.md (OCX_SIGSTORE_TUF_ROOT, OCX_OFFLINE x verify), signing.md (offline section + Rekor-pinning limitation update), user-guide air-gapped verify. ADR: adr_offline_verify_trust_cache.md.
When an operator [[trust.policy]] covers a package, `ocx package install` and `ocx package pull` now verify its keyless Sigstore signature at the metadata-first seam — after the manifest resolves, before any layer download — and abort fail-closed on a covered-but-invalid signature (no package-store or symlink state). No matching policy -> no verify (opt-in trust). `--no-verify` / `OCX_NO_VERIFY` opt out with a single WARN; the flag wins over the env. - PackageManager::verify_one: single lib verify facade over VerifyPipeline::run (#194); wraps VerifyError so the exit code survives the batch classifier. - AutoVerify + maybe_auto_verify hook (leader-only in setup_impl): gates on trust::resolve_tiered (#98), resolves trust material via the shared offline-aware ladder (#196), verifies against the resolved leaf digest. - oci::verify::resolve_trust_root: shared trust-root ladder (env/flag override -> cache -> embedded, offline gate) — one source of the offline gate for package verify and auto-verify. - OCX_NO_VERIFY env + OcxConfigView forwarding; --verify/--no-verify reused from options/verify.rs (flag > env). - Docs: environment.md, command-line.md, user-guide.md. - test/tests/test_auto_verify.py: 8 acceptance tests.
… v3 interop (#197) payload format and a single-hop certificate chain, not the public Sigstore wire format. That makes both #107 and #197 infeasible to ship in this milestone regardless of scheduling — Rekor v2 has no sigstore-rs client yet, and cosign cannot validate a bundle that isn't real-Sigstore-shaped no matter how the trust root is supplied. Ties the whole production-hardening cluster (Fulcio chain walking, Rekor SET format, TUF refresh, Rekor v2, cosign interop) into one roadmap section, and fixes an overclaim in the `package sign` reference that implied cosign could already verify OCX signatures.
Rebasing the milestone onto main (v0.4.2) required adapting to changes that landed after the branch was cut: - oci-client submodule advanced to upstream 7d27d20e (CA-root seeding, chunked push, referrers-with-artifactType). The branch's fork became redundant and is dropped; native_transport now uses upstream's ImageIndexEntry. Upstream's pull_referrers spec-fallback silently swallows a native 404 into an empty list, which would downgrade ReferrersUnsupported (84) to NoSignaturesFound (79); native_transport now calls the new pull_referrers_native (submodule) so the exit-84 contract survives. - SelectResult gained a FeatureMismatch variant (libc differentiation); sign/verify pipelines fold it into the no-target-manifest arm. - OciTransport::pull_blob_to_file slimmed to 4 params; the referrer capability test mock follows. - The managed-config [managed] tier and [trust] policy now coexist on Config (both merged); config_trust is cloned so the full config still moves into Context. - New 'ocx config' command group (managed-config) documented in subsystem-cli-commands.md; the branch's own CLI-coverage test learns the config group.
Eliminate the Block-tier "Never Re-Construct a Store" violation where two
private cache_path free functions hardcoded the state/ layout via literal
.join("state") chains.
Add named accessors StateStore::referrers_capability_file and trust_root_file
(slug via to_relaxed_slug, byte-identical paths) as the single layout owner,
mirroring the managed_config_* accessors. Retype the two cache seams
(ReferrersApiCapability, TrustRootCache) and thread a real &StateStore handle
through the sign/verify pipeline instead of downgrading to &Path via .root():
SignContext, VerifyContext, resolve_trust_root, VerifyOptions, AutoVerify, and
the CLI/context origin sites.
Behavior-preserving (Two Hats): on-disk paths unchanged, verified by
accessor-level path assertions plus the sign/verify acceptance suites.
Six call sites hand-rolled the same "write bytes to a 0o600 tempfile in the
target's parent dir, then publish atomically via persist_temp_file" sequence.
Extract it into utility::fs::write_bytes_atomic(target, bytes) and fold all six
onto it: the referrers + trust-root caches, the managed-config snapshot and
pause writers, and the setup profile + shim writers (deleting the local
write_atomic wrapper). Each site keeps its own create_dir_all and spawn_blocking.
Add the helper to the Utility Catalog (arch-principles.md) so it is discovered
before the next hand-rolled copy. Behavior-preserving: tempfile defaults to
0o600 on Unix, so the four plain NamedTempFile::new_in sites already produced
0o600 files; target paths and bytes are unchanged.
Excluded (folding would drop guarantees): blob_store (content-addressed
idempotency re-check + sync_data), project/{mutate,lock} (raw persist without
Windows retry, sync_data + parent-dir fsync durability, prior-perms
preservation), and state_store::touch_atomic (empty-marker touch, deliberate
plain rename).
Adversarial review (8-worker panel + Codex gpt-5.6-sol) of the signing & trust milestone surfaced 4 Block + ~19 actionable Warn findings across security, correctness, architecture, docs, and CLI. This applies them; the milestone's honest deferrals (cert-time binding, embedded TUF root, private SET / cosign interop) stay tracked for #205/#197. Security / correctness (verify pipeline): - Bind the Rekor transparency-log body to the bundle (parse the hashedrekord body; assert its digest/signature/cert match the subject + bundle + verified leaf). Closes the GHSA-whqx-f9j3-ch6m splice class where a leaked expired ephemeral key could attach a previously-valid SET to a new subject. New TransparencyBodyMismatch -> exit 65. - Cap the bundle-blob read pre-download and stream with take(MAX+1) so a registry lying about descriptor size cannot force an unbounded allocation (CWE-400); bound the referrer-manifest read by actual bytes (full streaming close deferred to #205). - ANY-of referrer verification: iterate every matching signature referrer (bounded count + byte budget), succeed on the first full crypto+policy pass, return a severity-ranked aggregate on failure. Fixes key rotation and the malformed-first-referrer DoS; a truncated candidate set returns CandidateLimitExhausted rather than misreporting a signed artifact as unsigned. - Regression fix: native_transport dropped the referrer descriptor artifactType, so the new client-side filter discarded every server-matched referrer. Preserve it at the transport; make the pipeline re-filter tolerant. - Shared, timed reqwest client for all four Sigstore call sites so a stalled endpoint cannot hang the fail-closed verify gate. - Replace a blocking is_dir() in the trust-root ladder; memoize the Rekor key per invocation; skip content-equal trust-cache writes. Architecture (ADR Amendment 1 -> Option 3): - SignPipeline/VerifyPipeline take &Client; demote Client::transport() to pub(crate); drop &dyn OciTransport from the public Sign/Verify contexts + VerifyOptions. Implement sign_one and route both CLI commands through the PackageManager facade (removes a reachable unimplemented!() panic + duplicated context assembly). SignOptions carries a Zeroizing identity token. Docs / schema truth: - Fix the trust.rs scope rustdoc (segment-boundary, not substring) schemars publishes into the config/project schemas; correct 9 website drifts; strip C-S1-* clause labels from clap help and extend the help-hygiene gate to catch them. CLI quality: - Single-source OCX_NO_VERIFY; split the shared verify help so install/pull states signature verification + OCX_NO_VERIFY; restore error.context.identifier on facade-wrapped failures; rename command/verify.rs -> package_verify.rs (+ catalog row); path context on the project-config read. Tests: SET-body splice, ANY-of rotation, malformed-first, size-cap, capped reads, envelope-identity-on-PackageError, sign 503->83 / wrong-key->80, offline warm-cache->77, ocx-run gate->79. The idempotent-re-sign test is xfail(strict) pending S1-I dedup. Deferred (tracked): streaming referrer-manifest read (#205), capability-probe singleflight, S1-I idempotent re-sign.
Post-rebase cross-file integration fixes the per-commit conflict resolution could not see: - sign/verify pipelines: Index::select now takes &Platform under the unified platform model; drop the Vec argument form - canonical_command_name: pick up main's upgrade->update rename and the new config setup subcommand - test_ai_config: map config_setup stem to 'config setup' in the command-table coverage test (test landed on branch, command on main)
michael-herwig
force-pushed
the
feat/signing-and-trust
branch
from
July 18, 2026 17:36
d31be9d to
e33cfe3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Milestone 2 — Signing & Trust v1
Delivers keyless Sigstore sign + verify over the OCI 1.1 Referrers API, identity-pinned trust policy, offline/air-gapped verify, and default-on auto-verify. Built on a long-living branch via per-issue swarm-loops; each feature is one commit, rebased cleanly onto current
main(v0.4.2).Closes #194 #195 #196 #98 #99 #106
What shipped (one commit per issue)
registry:2negative fixture; confirmedocx.shserves referrers.--trust-root/OCX_SIGSTORE_TRUST_ROOTseam; flat--format jsonverify report.ReferrersUnsupported(exit 84) error + capability-cache wiring (no re-probe within TTL).[trust.policy]inocx.toml#98 — identity-pinned verify via[[trust.policy]](config.tomloperator-authoritative over projectocx.toml; segment-boundary scope;identityXORidentity_regexp).ocx install/ocx pull(policy-gated) #99 — policy-gated auto-verify on every auto-install surface (install/pull/exec/env/run/patch), fail-closed no-partial-state,OCX_NO_VERIFYflag>env.Plus one integration commit adapting the milestone onto the current
main: origin/main'sSelectResult::FeatureMismatchvariant, the slimmed 4-argOciTransport::pull_blob_to_file, the newconfigcommand group (doc + coverage-test sync), and the upstreamoci-clientadoption (see below).Submodule (
external/rust-oci-client) — CI prerequisiteThe branch adopts upstream
oci-client7d27d20e(already onmain) plus one commit that addspull_referrers_native— a native-only referrers lookup that returnsOk(None)on a/v2/<name>/referrers/<digest>404 instead of the spec tag-schema fallback. Upstream'spull_referrerssilently swallows that 404 into an empty list, which would downgradeReferrersUnsupported(84) toNoSignaturesFound(79). The commit is on branchocx/referrers-native-detectionofocx-sh/rust-oci-client— must be pushed before CI can build.Deferred (stay open, tracked separately)
sigstore-rsadding a Rekor v2 client).Known scope (documented in
signing.md § Deferred to Future Work)v1 is keyless sign/verify against operator-supplied trust material + the in-repo fake Sigstore stack. Production public-good Sigstore (live Fulcio chain validation, standard Rekor wire SET, TUF trust root, cosign interop) is deferred and honestly documented — not claimed.
Verification
task verify(basic gate): green — 3016 Rust unit + ~1576 acceptance, clippy-D warnings, cargo-deny, hawkeye, actionlint, AI-config structural, link-check all clean.verify-deep.yml): zot +registry:2fixtures wired.